<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="css/normalize.min.css">
    <link rel="stylesheet" href="css/base.css">
    <title>第九章 扩展商店功能</title>

</head>
<body>
<h1 id="top"><b>第九章 扩展商店功能</b></h1>
<p>在上一章里，为电商站点集成了支付功能，然后可以生成PDF发票发送给用户。在本章，我们将为商店添加优惠码功能。此外，还会学习国际化和本地化的设置和建立一个推荐商品的系统。</p>
<p>本章涵盖如下要点：</p>
<ul>
    <li>建立一个优惠券系统，可以实现折扣功能</li>
    <li>给项目增加国际化功能</li>
    <li>使用Rosetta来管理翻译</li>
    <li>使用Django-parler翻译模型</li>
    <li>建立商品推荐系统</li>
</ul>

<h2 id="c9-1"><span class="title">1</span>优惠码系统</h2>
<p>很多电商网站，会向用户发送电子优惠码，以便用户在购买时使用，以折扣价进行结算。一个在线优惠码通常是一个字符串，然后还规定了有效期限，一次性有效或者可以反复使用。</p>
<p>我们将为站点添加优惠码功能。我们的优惠码带有有效期，但是不限制使用次数，输入之后，就会影响用户购物车中的总价。为了实现这个需求，需要建立一个数据模型来存储优惠码，有效期和对应的折扣比例。</p>
<p>为<code>myshop</code>项目创建新的应用<code>coupons</code>：</p>
<pre>python manage.py startapp coupons</pre>
<p>然后在<code>settings.py</code>内激活该应用：</p>
<pre>
INSTALLED_APPS = [
    # ...
    <b>'coupons.apps.CouponsConfig',</b>
]
</pre>

<h3 id="c9-1-1"><span class="title">1.1</span>创建优惠码数据模型</h3>
<p>编辑<code>coupons</code>应用的<code>models.py</code>文件，创建一个<code>Coupon</code>模型：</p>
<pre>
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator

class Coupon(models.Model):
    code = models.CharField(max_length=50, unique=True)
    valid_from = models.DateTimeField()
    valid_to = models.DateTimeField()
    discount = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(100)])
    active = models.BooleanField()

    def __str__(self):
        return self.code
</pre>
<p>这是用来存储优惠码的模型，<code>Coupon</code>模型包含以下字段：</p>
<ul>
    <li><code>code</code>：用于存放码的字符串</li>
    <li><code>valid_from</code>：优惠码有效期的开始时间。</li>
    <li><code>valid_to</code>：优惠码有效期的结束时间。</li>
    <li><code>discount</code>：该券对应的折扣，是一个百分比，所以取值为<code>0-100</code>，我们使用了内置验证器控制该字段的取值范围。</li>
    <li><code>active</code>：表示该码是否有效</li>
</ul>
<p>之后执行数据迁移程序。然后将<code>Coupon</code>模型加入到管理后台，编辑<code>coupons</code>应用的<code>admin.py</code>文件：</p>
<pre>
from django.contrib import admin
from .models import Coupon

class CouponAdmin(admin.ModelAdmin):
    list_display = ['code', 'valid_from', 'valid_to', 'discount', 'active']
    list_filter = ['active', 'valid_from', 'valid_to']
    search_fields = ['code']

admin.site.register(Coupon, CouponAdmin)
</pre>
<p>现在启动站点，到<a href="http://127.0.0.1:8000/admin/coupons/coupon/add/" target="_blank">http://127.0.0.1:8000/admin/coupons/coupon/add/</a>查看<code>Coupon</code>模型：</p>
<p><img src="http://img.conyli.cc/django2/C09-01.jpg" alt=""></p>
<p>输入一个优惠码记录，有效期设置为当前日期，不要忘记勾上Active然后点击SAVE按钮。</p>

<h3 id="c9-1-2"><span class="title">1.2</span>为购物车增加优惠码功能</h3>
<p>创建数据模型之后，可以查询和获得优惠码对象。现在我们必须增添使用户可以输入优惠码从而获得折扣价的功能。这个功能将按照如下逻辑进行操作：</p>
<ol>
    <li>用户添加商品到购物车</li>
    <li>用户能通过购物车详情页面的表单输入一个优惠码</li>
    <li>输入优惠码并提交表单之后，需要来判断该码是否在数据库中存在、当前时间是否在<code>valid_from</code>和<code>valid_to</code>有效时间之间、<code>active</code>属性是否为<code>True</code>。</li>
    <li>如果优惠码通过上述检查，将优惠码的信息保存在<code>session</code>中，用折扣重新计算价格并更新购物车中的商品价格</li>
    <li>用户提交订单时，将优惠码保存在订单对象中。</li>
</ol>
<p>在<code>coupons</code>应用里建立<code>forms.py</code>文件，添加下列代码：</p>
<pre>
from django import forms

class CouponApplyForm(forms.Form):
    code = forms.CharField()
</pre>
<p>这个表单用于用户输入优惠码。然后来编辑<code>coupons</code>应用的<code>views.py</code>文件：</p>
<pre>
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views.decorators.http import require_POST
from .models import Coupon
from .forms import CouponApplyForm

@require_POST
def coupon_apply(request):
    now = timezone.now()
    form = CouponApplyForm(request.POST)
    if form.is_valid():
        code = form.cleaned_data['code']
        try:
            coupon = Coupon.objects.get(code__iexact=code, valid_from__lte=now, valid_to__gte=now, active=True)
            request.session['coupon_id'] = coupon.id
        except Coupon.DoesNotExist:
            request.session['coupon_id'] = None
    return redirect('cart:cart_detail')
</pre>
<p>这个<code>coupon_apply</code>视图验证优惠码并将其存储在session中，使用了<code>@require_POST</code>装饰器令该视图仅接受<code>POST</code>请求。这个视图的业务逻辑如下：</p>
<ol>
    <li>使用请求中的数据初始化<code>CouponApplyForm</code></li>
    <li>如果表单通过验证，从表单的<code>cleaned_data</code>获取<code>code</code>，然后使用<code>code</code>查询数据库得到<code>coupon</code>对象，这里使用了过滤参数<code>iexact</code>，进行完全匹配；使用<code>active=True</code>过滤出有效的优惠码；使用<code>timezone.now()</code>获取当前时间，<code>valid_from</code>和<code>valid_to</code>分别采用<code>lte</code>（小于等于）和<code>gte</code>（大于等于）过滤查询以保证当前时间位于有效期内。</li>
    <li>将优惠码ID存入当前用户的session。</li>
    <li>重定向到<code>cart_detail</code> URL对应的购物车详情页，以显示应用了优惠码之后的金额。</li>
</ol>
<p>需要为<code>coupon_apply</code>视图配置URL，在<code>coupons</code>应用中建立<code>urls.py</code>文件，添加下列代码：</p>
<pre>
from django.urls import path
from . import views

app_name = 'coupons'
urlpatterns = [
    path('apply/', views.coupon_apply, name='apply'),
]
</pre>
<p>然后编辑项目的根路由，增加一行：</p>
<pre>
urlpatterns = [
    # ...
    <b>path('coupons/', include('coupons.urls', namespace='coupons')),</b>
    path('', include('shop.urls', namespace='shop')),
]
</pre>
<p>依然记得要把这一行放在<code>shop.urls</code>上方。</p>
<p>编辑<code>cart</code>应用中的<code>cart.py</code>文件，添加下列导入：</p>
<pre>
from coupons.models import Coupon
</pre>
<p>然后在<code>cart</code>类的<code>__init__()</code>方法的最后添加从session中获得优惠码ID的语句：</p>
<pre>
class Cart(object):
    def __init__(self, request):
        # ...
        <b># store current applied coupon</b>
        <b>self.coupon_id = self.session.get('coupon_id')</b>

</pre>
<p>在<code>Cart</code>类中，我们需要通过<code>coupon_id</code>获取优惠码信息并将其保存在<code>Cart</code>对象内，为<code>Cart</code>类添加如下方法：</p>
<pre>
class Cart(object):
    # ...
    <b>@property</b>
    <b>def coupon(self):</b>
        <b>if self.coupon_id:</b>
            <b>return Coupon.objects.get(id=self.coupon_id)</b>
        <b>return None</b>

    <b>def get_discount(self):</b>
        <b>if self.coupon:</b>
            <b>return (self.coupon.discount / Decimal('100')) * self.get_total_price()</b>
        <b>return Decimal('0')</b>

    <b>def get_total_price_after_diccount(self):</b>
        <b>return self.get_total_price() - self.get_discount()</b>
</pre>
<p>这些方法解释如下：</p>
<ul>
    <li><code>coupon()</code>：我们使用<code>@property</code>将该方法定义为属性，如果购物车包含一个<code>coupon_id</code>属性，会返回该id对应的<code>Coupon</code>对象</li>
    <li><code>get_discount()</code>：如果包含优惠码id，计算折扣价格，否则返回0。</li>
    <li><code>get_total_price_after_discount()</code>：返回总价减去折扣价之后的折扣后价格。</li>
</ul>
<p>现在<code>Cart</code>类就具备了根据优惠码计算折扣价的功能。</p>
<p>现在还需要修改购物车详情视图函数，以便在页面中应用表单和展示折扣金额，修改<code>cart</code>应用的<code>views.py</code>文件，增加导入代码：</p>
<pre>from coupons.forms import CouponApplyForm
</pre>
<p>然后修改<code>cart_detail</code>视图，添加表单：</p>
<pre>
def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(initial={'quantity': item['quantity'], 'update': True})
    <b>coupon_apply_form = CouponApplyForm()</b>
    return render(request, 'cart/detail.html', {'cart': cart, <b>'coupon_apply_form': coupon_apply_form</b>})
</pre>
<p>修改<code>cart</code>应用的购物车模板<code>cart/detail.html</code>，找到如下几行：</p>
<pre>
&lt;tr class="total">
    &lt;td>total&lt;/td>
    &lt;td colspan="4">&lt;/td>
    &lt;td class="num">${{ cart.get_total_price }}&lt;/td>
&lt;/tr>
</pre>
<p>替换成如下代码：</p>
<pre>
{% if cart.coupon %}
    &lt;tr class="subtotal">
        &lt;td>Subtotal&lt;/td>
        &lt;td colspan="4">&lt;/td>
        &lt;td class="num">${{ cart.get_total_price_after_diccount }}&lt;/td>
    &lt;/tr>
    &lt;tr>
        &lt;td>"{{ cart.coupon.code }}" coupon ({{ cart.coupon.discount }}% off)&lt;/td>
        &lt;td colspan="4">&lt;/td>
        &lt;td class="num neg">- ${{ cart.get_discount|floatformat:"2" }}&lt;/td>
    &lt;/tr>
{% endif %}

    &lt;tr class="total">
        &lt;td>Total&lt;/td>
        &lt;td colspan="4">&lt;/td>
        &lt;td class="num">${{ cart.get_total_price_after_diccount|floatformat:"2" }}&lt;/td>
    &lt;/tr>
</pre>
<p>这是新的购物车模板。如果包含一个优惠券，就展示一行购物车总价，再展示一行优惠券信息，最后通过<code>get_total_price_after_discount()</code>展示折扣后价格。</p>
<p>在同一个文件内，在<code>&lt;/table&gt;</code>后增加下列代码：</p>
<pre>
{# 在紧挨着&lt;/table>标签之后插入： #}
&lt;p>Apply a coupon:&lt;/p>
&lt;form action="{% url 'coupons:apply' %}" method="post">
    {{ coupon_apply_form }}
    &lt;input type="submit" value="Apply">
    {% csrf_token %}
&lt;/form>
</pre>
<p>上边这段代码展示输入优惠码的表单。</p>
<p>在浏览器中打开<code>http://127.0.0.1:8000/</code>，向购物车内加入一些商品，然后进入购物车页面输入优惠码并提交，可以看到如下所示：</p>
<p><img src="http://img.conyli.cc/django2/C09-02.jpg" alt=""></p>
<p>之后来修改订单模板<code>orders/order/create.html</code>，在其中找到如下部分：</p>
<pre>
&lt;ul>
    {% for item in cart %}
    &lt;li>
        {{ item.quantity }} x {{ item.product.name }}
        &lt;span>${{ item.total_price }}&lt;/span>
    &lt;/li>
    {% endfor %}
&lt;/ul>
</pre>
<p>替换成：</p>
<pre>
&lt;ul>
    {% for item in cart %}
        &lt;li>
            {{ item.quantity }}x {{ item.product.name }}
            &lt;span>${{ item.total_price|floatformat:"2" }}&lt;/span>
        &lt;/li>
    {% endfor %}
    {% if cart.coupon %}
        &lt;li>
            "{{ cart.coupon.code }}" ({{ cart.coupon.discount }}% off)
            &lt;span>- ${{ cart.get_discount|floatformat:"2" }}&lt;/span>
        &lt;/li>
    {% endif %}
&lt;/ul>
</pre>
<p>如果有优惠码，现在的订单页面就展示优惠码信息了。继续找到下边这行：</p>
<pre>
&lt;p>Total: ${{ cart.get_total_price }}&lt;/p>
</pre>
<p>替换成：</p>
<pre>
&lt;p>Total: ${{ cart.get_total_price_after_diccount|floatformat:"2" }}&lt;/p>
</pre>
<p>这样总价也变成了折扣后价格。</p>
<p>在浏览器中打开<code>http://127.0.0.1:8000/</code>，添加商品到购物车然后生成订单，可以看到订单页面的价格现在是折扣后的价格了：</p>
<p><img src="http://img.conyli.cc/django2/C09-03.jpg" alt=""></p>

<h3 id="c9-1-3"><span class="title">1.3</span>在订单中记录优惠码信息</h3>
<p>像之前说的，我们需要将优惠码信息保存至<code>order</code>对象中，为此需要修改<code>Order</code>模型。编辑</p>
<p>编辑<code>orders</code>应用的<code>models.py</code>文件，增加导入部分的代码：</p>
<pre>
from decimal import Decimal
from django.core.validators import MinValueValidator, MaxValueValidator
from coupons.models import Coupon
</pre>
<p>然后为<code>Order</code>模型增加下列字段：</p>
<pre>
class Order(models.Model):
    coupon = models.ForeignKey(Coupon, related_name='orders', null=True, blank=True, on_delete=models.SET_NULL)
    discount = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)])
</pre>
<p>这两个字段用于存储优惠码信息。虽然折扣信息保存在Coupon对象中，但这里还是用<code>discount</code>字段保存了当前的折扣，以免未来优惠码折扣发生变化。为<code>coupon</code>字段设置了<code>on_delete=models.SET_NULL</code>，优惠码删除时，该外键字段会变成空值。</p>
<p>增加好字段后数据迁移程序。回到<code>models.py</code>文件，需要修改<code>Order</code>类中的<code>get_total_cost()</code>方法：</p>
<pre>
class Order(models.Model):
    # ...
    def get_total_cost(self):
        <b>total_cost</b> = sum(item.get_cost() for item in self.items.all())
        <b>return total_cost - total_cost * (self.discount / Decimal('100'))</b>
</pre>
<p>修改后的<code>get_total_cost()</code>方法会把折扣也考虑进去。之后还需要修改<code>orders</code>应用里的<code>views.py</code>文件中的<code>order_create</code>视图，以便在生成订单的时候，存储这两个新增的字段。找到下边这行：</p>
<pre>order = form.save()</pre>
<p>将其替换成如下代码：</p>
<pre>
order = form.save(<b>commit=False</b>)
<b>if cart.coupon:</b>
    <b>order.coupon = cart.coupon</b>
    <b>order.discount = cart.coupon.discount</b>
<b>order.save()</b>
</pre>
<p>在修改后代码中，通过调用<code>OrderCreateForm</code>表单对象的<code>save()</code>方法，创建一个<code>order</code>对象，使用<code>commit=False</code>暂不存入数据库。如果购物车对象中有折扣信息，就保存折扣信息。然后将<code>order</code>对象存入数据库。</p>
<p>启动站点，在浏览器中访问<a href="http://127.0.0.1:8000/" target="_blank">http://127.0.0.1:8000/</a>，使用一个自己创建的优惠码，在完成购买之后，可以到<a href="http://127.0.0.1:8000/admin/orders/order/" target="_blank">http://127.0.0.1:8000/admin/orders/order/></a>查看包含优惠码和折扣信息的订单：</p>
<p><img src="http://img.conyli.cc/django2/C09-04.jpg" alt=""></p>
<p>还可以修改管理后台的订单详情页和和PDF发票，以使其包含优惠码和折扣信息。下边我们将为站点增加国际化功能。</p>
<p class="emp">译者注：这里有一个问题：用户提交了订单并清空购物车后，如果再向购物车内添加内容，再次进入购物车详情页面可以发现自动使用了上次使用的优惠券。此种情况的原因是作者把优惠券信息附加到了session上，在提交订单的时候没有清除。cart对象实例化的时候又取到了相同的优惠券信息。所以需要对程序进行一下改进。</p>
<p class="emp">修改<code>orders</code>应用的<code>order_create</code>视图，在生成<code>OrderItem</code>并清空购物车的代码下增加一行：</p>
<pre>
def order_create(request):
    cart = Cart(request)
    if request.method == "POST":
        form = OrderCreateForm(request.POST)
        # 表单验证通过就对购物车内每一条记录生成OrderItem中对应的一条记录
        if form.is_valid():
            order = form.save(commit=False)
            if cart.coupon:
                order.coupon = cart.coupon
                order.discount = cart.coupon.discount
            order.save()
            for item in cart:
                OrderItem.objects.create(order=order, product=item['product'], price=item['price'],
                                         quantity=item['quantity'])
            # 成功生成OrderItem之后清除购物车
            cart.clear()

            <b># 清除优惠券信息</b>
            <b>request.session['coupon_id'] = None</b>

            # 成功完成订单后调用异步任务发送邮件
            order_created.delay(order.id)
            # 在session中加入订单id
            request.session['order_id'] = order.id
            # 重定向到支付页面
            return redirect(reverse('payment:process'))

    else:
        form = OrderCreateForm()
    return render(request, 'orders/order/create.html', {'cart': cart, 'form': form})
</pre>

<h2 id="c9-2"><span class="title">2</span>国际化与本地化</h2>
<p>Django对于国际化和本地化提供了完整的支持，允许开发者将站点内容翻译成多种语言，而且可以处理本地化的时间日期数字和时区格式等本地化的显示内容。在开始之前，先需要区分一下<a href="https://en.wikipedia.org/wiki/Internationalization_and_localization" target="_blank">国际化和本地化</a>两个概念。国际化和本地化都是一种软件开发过程。国际化（Internationalization，通常缩写为<b>i18n</b>），是指一个软件可以被不同的国家和地区使用，而不会局限于某种语言。本地化（Localization，缩写为<b>l10n</b>）是指对国际化的软件将其进行翻译或者其他本地化适配，使之变成适合某一个国家或地区使用的软件的过程。Django通过自身的国际化框架，可以支持超过50种语言。</p>

<h3 id="c9-2-1"><span class="title">2.1</span>国际化与本地化设置</h3>
<p>Django的国际化框架可以让开发者很方便的在Python代码和模板中标注需要翻译的字符串，这个框架依赖于GNU gettext开源软件来生成和管理<b>消息文件（message file</b>)。消息文件是一个纯文本文件，代表一种语言的翻译，存放着在站点应用中找到的部分或者所有需要翻译的字符串以及对应的某种语言的翻译，就像一个字典一样。消息文件的后缀名是<code>.po</code>。</p>
<p>一旦完成翻译，可以把消息文件编译，以快速访问翻译内容，编译后的消息文件的后缀名是<code>.mo</code>。</p>

<h4 id="c9-2-1-1"><span class="title">2.1.1</span>国际化与本地化设置</h4>
<p>Django提供了一些国际化和本地化的设置，下边一些设置是最重要的：</p>
<ul>
    <li><code>USE_I18N</code>：布尔值，是否启用国际化功能，默认为<code>True</code></li>
    <li><code>USE_L10N</code>：布尔值，设置本地化功能是否启用，设置为<code>True</code>时，数字和日期将采用本地化显示。默认为<code>False</code></li>
    <li><code>USE_TZ</code>：布尔值，指定时间是否根据时区进行调整，当使用<code>startproject</code>创建项目时，默认为<code>True</code></li>
    <li><code>LANGUAGE_CODE</code>：项目的默认语言代码，采用标准的语言代码格式，例如'en-us'表示美国英语，'en-gb'表示英国英语。这个设置需要<code>USE_I18N</code>设置为<code>True</code>才会生效。在<a href="http://www.i18nguy.com/unicode/language-identifiers.html" target="_blank">http://www.i18nguy.com/unicode/language-identifiers.html</a>可以找到语言代码清单。</li>
    <li><code>LANGUAGES</code>：一个包含项目所有可用语言的元组，其中每个元素是语言代码和语言名称构成的二元组。可以在<code>django.conf.global_settings</code>查看所有可用的语言。这个属性可设置的值必须是<code>django.conf.global_settings</code>中列出的值。</li>
    <li><code>LOCALE_PATHS</code>：一个目录列表，目录内存放项目的翻译文件。</li>
    <li><code>TIME_ZONE</code>：字符串，代表项目所采用的时区。如果使用<code>startproject</code>启动项目，该值被设置为<code>'UTC'</code>。可以按照实际情况将其设置为具体时区，如<code>'Europe/Madrid'</code>。中国的时区是<code>'Asia/Shanghai'</code>，大小写敏感。</li>
</ul>
<p>以上是常用的国际化和本地化设置，完整设置请参见<a href="https://docs.djangoproject.com/en/2.1/ref/settings/#globalization-i18n-l10n" target="_blank">https://docs.djangoproject.com/en/2.1/ref/settings/#globalization-i18n-l10n</a>。</p>

<h4 id="c9-2-1-2"><span class="title">2.1.2</span>国际化和本地化管理命令</h4>
<p>Django包含了用于管理翻译的命令如下：</p>
<ul>
    <li><code>makemessages</code>：运行该命令，会找到项目中所有标注要翻译的字符串，建立或者更新<code>locale</code>目录下的<code>.po</code>文件，每种语言会生成单独的<code>.po</code>文件。</li>
    <li><code>compilemessages</code>：编译所有的<code>.po</code>文件为<code>.mo</code>文件。</li>
</ul>
<p>需要使用GNU gettext工具来执行上述过程，大部分linux发行版自带有该工具。如果在使用mac OSX，可以通过 <a href="http://brew.sh/" target="_blank">http://brew.sh/</a> 使用命令<code>brew install gettext</code>来安装，之后使用<code>brew link gettext --force</code>强制链接。对于Windows下的安装，参考<a
        href="https://docs.djangoproject.com/en/2.0/topics/i18n/translation/#gettext-on-windows" target="_blank">https://docs.djangoproject.com/en/2.0/topics/i18n/translation/#gettext-on-windows</a>中的步骤。</p>

<h4 id="c9-2-1-3"><span class="title">2.1.3</span>如何为项目增加翻译文件</h4>
<p>先来看一下增加翻译需要进行的流程：</p>
<ol>
    <li>在Python代码和模板中标注出需要翻译的字符串</li>
    <li>运行<code>makemessages</code>命令建立消息文件</li>
    <li>在消息文件中将字符串翻译成另外一种语言，然后运行<code>compilemessages</code>命令编译消息文件</li>
</ol>

<h4 id="c9-2-1-4"><span class="title">2.1.4</span>Django如何确定当前语言</h4>
<p>Django使用中间件<code>django.middleware.locale.LocaleMiddleware</code>来检查HTTP请求中所使用的本地语言。这个中间件做的工作如下：</p>
<ol>
    <li>如果使用<a href="https://docs.djangoproject.com/en/2.0/topics/i18n/translation/#language-prefix-in-url-patterns" target="_blank">i18_patterns</a>（django特殊的一种URL方式，里边包含语言前缀），中间件会在请求的URL中寻找特定语言的前缀</li>
    <li>如果在URL中没有发现语言前缀，会在session中寻找一个键<code>LANGUAGE_SESSION_KEY</code></li>
    <li>如果session中没有该键，会在cookie中寻找一个键。可以通过<code>LANGUAGE_COOKIE_NAME</code>自定义该cookie的名称，默认是<code>django_language</code></li>
    <li>如果cookie中未找到，找HTTP请求头的<code>Accept-Language</code>键</li>
    <li>如果<code>Accept-Language</code>头部信息未指定具体语言，则使用<code>LANGUAGE_CODE</code>设置</li>
</ol>
<p>注意这个过程只有在开启了该中间件的时候才会得到完整执行，如果未开启中间件，Django直接使用<code>LANGUAGE_CODE</code>中的设置。</p>

<h3 id="c9-2-2"><span class="title">2.2</span>为项目使用国际化进行准备</h3>
<p>我们准备为电商网站增添各种语言的支持，增添英语和西班牙语的支持。编辑<code>settings.py</code>文件，加入<code>LANGUAGES</code>设置，放在<code>LANGUAGE_CODE</code>的旁边：</p>
<pre>
LANGUAGES = (
    ('en', 'English'),
    ('es', 'Spanish'),
)
</pre>
<p><code>LANGUAGES</code>设置包含两个语言代码和名称组成的元组。语言代码可以指定具体语言如<code>en-us</code>或<code>en-gb</code>，也可以更模糊，如<code>en</code>。通过这个设置，我们定义了我们的网站仅支持英语和西班牙语。如果不定义<code>LANGUAGES</code>设置，默认支持所有django支持的语言。</p>
<p>设置<code>LANGUAGE_CODE</code>为如下：</p>
<pre>LANGUAGE_CODE = 'en'</pre>
<p>添加<code>django.middleware.locale.LocaleMiddleware</code>到<code>settings.py</code>的中间件设置中，位置在<code>SessionMiddleware</code>中间件之后，<code>CommonMiddleware</code>中间件之前，因为<code>LocaleMiddleware</code>需要使用session，而<code>CommonMiddleware</code>需要一种可用语言来解析URL，<code>MIDDLEWARE</code>设置成如下：</p>
<pre>
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    <b>'django.middleware.locale.LocaleMiddleware',</b>
    'django.middleware.common.CommonMiddleware',
    # ...
]
</pre>
<p class="hint">django中间件设置的顺序很重要，中间件会在请求上附加额外的数据，某个中间件会依赖于另外一个中间件附加的数据才能正常工作。</p>
<p>在manage.py文件所在的项目根目录下创建如下目录：</p>
<pre>
locale/
    en/
    es/
</pre>
<p><code>locale</code>目录是用来存放消息文件的目录，编辑<code>settings.py</code>文件加入如下设置：</p>
<pre>
LOCALE_PATH = (
    os.path.join(BASE_DIR, 'locale/'),
)
</pre>
<p><code>LOCALE_PATH</code>指定了Django寻找消息文件的路径，可以是一系列路径，最上边的路径优先级最高。</p>
<p>当使用<code>makemessages</code>命令的时候，消息文件会在我们创建的<code>locale/</code>目录中创建，如果某个应用也有<code>locale/</code>目录，那个应用中的翻译内容会优先在那个应用的目录中创建。</p>

<h3 id="c9-2-3"><span class="title">2.3</span>翻译Python代码中的字符串</h3>
<p>为了翻译Python代码中的字符串字面量，需要使用<code>django.utils.translation</code>模块中的<code>gettext()</code>方法来标注字符串。这个方法返回翻译后的字符串，通常做法是导入该方法然后命名为一个下划线"_"。可以在<a href="https://docs.djangoproject.com/en/2.0/topics/i18n/translation/" target="_blank">https://docs.djangoproject.com/en/2.0/topics/i18n/translation/</a>查看文档。</p>

<h4 id="c9-2-3-1"><span class="title">2.3.1</span>标记字符串</h4>
<p>标记字符串的方法如下：</p>
<pre>
from django.utils.translation import gettext as _
output = _('Text to be translated.')
</pre>

<h4 id="c9-2-3-2"><span class="title">2.3.2</span>惰性翻译</h4>
<p>Django对于所有的翻译函数都有惰性版本，后缀为<code>_lazy()</code>。使用惰性翻译函数的时候，字符串只有被访问的时候才会进行翻译，而不是在翻译函数调用的时候。当字符串位于模块加载的时候才生成的路径中时候特别有效。</p>
<p class="hint">使用<code>gettext_lazy()</code>代替<code>gettext()</code>方法，只有在该字符串被访问的时候才会进行翻译，所有的翻译函数都有惰性版本。。</p>

<h4 id="c9-2-3-3"><span class="title">2.3.3</span>包含变量的翻译</h4>
<p>被标注的字符串中还可以带有占位符，以下是一个占位符的例子：</p>
<pre>
from django.utils.translation import gettext as _
month = _('April')
day = '14'
output = _('Today is %(month)s %(day)s') % {'month': month, day': day}
</pre>
<p>通过使用占位符，可以使用字符串变量。例如，上边这个例子的英语如果是<em>"Today is April 14"</em>，翻译成的西班牙语就是<em>"Hoy es 14 de Abril"</em>。当需要翻译的文本中存在变量的时候，推荐使用占位符。</p>

<h4 id="c9-2-3-4"><span class="title">2.3.4</span>复数的翻译</h4>
<p>对于复数形式的翻译，可以采用<code>ngettext()</code>和<code>ngettext_lazy()</code>。这两个函数根据对象的数量来翻译单数或者复数。使用例子如下：</p>
<pre>
output = ngettext('there is %(count)d product', 'there are %(count)d products', count) % {'count': count}
</pre>
<p>现在我们了解了Python中翻译字面量的知识，可以来为我们的项目添加翻译功能了。</p>

<h4 id="c9-2-3-5"><span class="title">2.3.5</span>为项目翻译Python字符串字面量</h4>
<p>编辑<code>setttings.py</code>，导入<code>gettext_lazy()</code>，然后修改<code>LANGUAGES</code>设置：</p>
<pre>
from django.utils.translation import gettext_lazy as _

LANGUAGES = (
    ('en', _('English')),
    ('es', _('Spanish')),
)
</pre>
<p>这里导入了<code>gettext_lazy()</code>并使用了别名"_"来避免重复导入。将显示的名称也进行了翻译，这样对于不同的语言的人来说，可以看懂并选择他自己的语言。</p>
<p>然后打开系统命令行窗口，输入如下命令：</p>
<pre>
django-admin makemessages --all
</pre>
<p>可以看到如下输出：</p>
<pre>
processing locale en
processing locale es
</pre>
<p>然后查看项目的<code>locale</code>目录，可以看到如下文件和目录结构：</p>
<pre>
en/
    LC_MESSAGES/
        django.po
es/
    LC_MESSAGES/
        django.po
</pre>
<p>每个语言都生成了一个<code>.po</code>消息文件，使用文本编辑器打开<code>es/LC_MESSAGES/django.po</code>文件，在末尾可以看到如下内容：</p>
<pre>
#: .\myshop\settings.py:107
msgid "English"
msgstr ""

#: .\myshop\settings.py:108
msgid "Spanish"
msgstr ""
</pre>
<p>每一部分的第一行表示在那个文件的第几行发现了需翻译的内容，每个翻译包含两个字符串：</p>
<ul>
    <li><code>msgid</code>：源代码中的字符串</li>
    <li><code>msgstr</code>：被翻译成的字符串，默认为空，需要手工添加。</li>
</ul>
<p>添加好翻译之后的文件如下：</p>
<pre>
#: myshop/settings.py:117
msgid "English"
msgstr "Inglés"

#: myshop/settings.py:118
msgid "Spanish"
msgstr "Español"
</pre>
<p>保存这个文件，之后执行命令编译消息文件：</p>
<pre>
django-admin compilemessages
</pre>
<p>可以看到输出如下：</p>
<pre>
processing file django.po in myshop/locale/en/LC_MESSAGES
processing file django.po in myshop/locale/es/LC_MESSAGES
</pre>
<p>这表明已经编译了翻译文件，此时查看<code>locale</code>目录，其结构如下：</p>
<pre>
en/
    LC_MESSAGES/
        django.mo
        django.po
es/
    LC_MESSAGES/
        django.mo
        django.po
</pre>
<p>可以看到每种语言都生成了<code>.mo</code>文件。</p>
<p>我们已经翻译好了语言名称本身。现在我们来试着翻译一下<code>Order</code>模型的所有字段，修改<code>orders</code>应用的<code>models.py</code>文件：</p>
<pre>
<b>from django.utils.translation import gettext_lazy as _</b>

class Order(models.Model):
    first_name = models.CharField(<b>_('frist name')</b>, max_length=50)
    last_name = models.CharField(<b>_('last name')</b>, max_length=50)
    email = models.EmailField(<b>_('e-mail')</b>, )
    address = models.CharField(<b>_('address')</b>, max_length=250)
    postal_code = models.CharField(<b>_('postal code')</b>, max_length=20)
    city = models.CharField(<b>_('city'</b>), max_length=100)
    ......
</pre>
<p>我们为每个显示出来的字段标记了翻译内容，也可以使用<code>verbose_name</code>属性来命名字段。在<code>orders</code>应用中建立如下目录：</p>
<pre>
locale/
    en/
    es/
</pre>
<p>通过创建<code>locale</code>目录，当前应用下的翻译内容会优先保存到这个目录中，而不是保存在项目根目录下的<code>locale</code>目录中。这样就可以为每个应用配置独立的翻译文件。</p>
<p>在系统命令行中执行：</p>
<pre>
django-admin makemessages --all
</pre>
<p>输出为：</p>
<pre>
processing locale es
processing locale en
</pre>
<p>使用文本编辑器打开<code>locale/es/LC_MESSAGES/django.po</code>，可以看到Order模型的字段翻译，在<code>msgstr</code>中为对应的<code>msgid</code>字符串加上西班牙语的翻译：</p>
<pre>
#: orders/models.py:10
msgid "first name"
msgstr "nombre"

#: orders/models.py:11
msgid "last name"
msgstr "apellidos"

#: orders/models.py:12
msgid "e-mail"
msgstr "e-mail"

#: orders/models.py:13
msgid "address"
msgstr "dirección"

#: orders/models.py:14
msgid "postal code"
msgstr "código postal"

#: orders/models.py:15
msgid "city"
msgstr "ciudad"
</pre>
<p>添加完翻译之后保存文件。</p>
<p>除了常用的文本编辑软件，还可以考虑使用Poedit编辑翻译内容，该软件同样依赖gettext，支持Linux，Windows和macOS X。可以在<a href="https://poedit.net/" target="_blank">https://poedit.net/</a>下载该软件。</p>
<p>下边来翻译项目使用的表单。<code>OrderCreateForm</code>这个表单类无需翻译，因为它会自动使用<code>Order</code>类中我们刚刚翻译的<code>verbose_name</code>。现在我们去翻译<code>cart</code>和<code>coupons</code>应用中的内容。</p>
<p>在<code>cart</code>应用的<code>forms.py</code>文件中，导入翻译函数，为<code>CartAddProductForm</code>类的<code>quantity</code>字段增加一个参数<code>label</code>，代码如下：</p>
<pre>
from django import forms
<b>from django.utils.translation import gettext_lazy as _</b>
<b style="color: red;">PRODUCT_QUANTITY_CHOICES = [(i, str(i)) for i in range(1, 21)]</b>

class CartAddProductForm(forms.Form):
    quantity = forms.TypedChoiceField(choices=PRODUCT_QUANTITY_CHOICES, coerce=int, <b>label=_('Quantity')</b>)
    update = forms.BooleanField(required=False, initial=False, widget=forms.HiddenInput)
</pre>
<p class="emp">译者注：红字部分是本书上一版的遗留，无任何作用，读者可以忽略。</p>
<p>之后修改<code>coupons</code>应用的<code>forms.py</code>文件，为<code>CouponApplyForm</code>类增加翻译：</p>
<pre>
from django import forms
<b>from django.utils.translation import gettext_lazy as _</b>

class CouponApplyForm(forms.Form):
    code = forms.CharField(<b>label=_('Coupon')</b>)
</pre>
<p>我们为<code>code</code>字段增加了一个label标签用于展示翻译后的字段名称。</p>

<h3 id="c9-2-4"><span class="title">2.4</span>翻译模板</h3>
<p>Django为翻译模板内容提供了<code>{% trans %}</code>和<code>{% blocktrans %}</code>两个模板标签用于翻译内容，如果要启用这两个标签，需要在模板顶部加入<code>{% load i18n %}</code>。</p>

<h4 id="c9-2-4-1"><span class="title">2.4.1</span>使用<code>{% trans %}</code>模板标签</h4>
<p><code>{% trans %}</code>标签用来标记一个字符串，常量或者变量用于翻译。Django内部也是该文本执行<code>gettext()</code>等翻译函数。标记字符串的例子是：</p>
<pre>{% trans "Text to be translated" %}</pre>
<p>也可以像其他标签变量一样，使用as 将 翻译后的结果放入一个变量中，在其他地方使用。下面的例子使用了一个变量<code>greeting</code>：</p>
<pre>
{% trans "Hello!" as greeting %}
&lt;h1>{{ greeting }}&lt;/h1>
</pre>
<p>这个标签用于比较简单的翻译，但不能用于带占位符的文字翻译。</p>

<h4 id="c9-2-4-2"><span class="title">2.4.2</span>使用<code>{% blocktrans %}</code>模板标签</h4>
<p><code>{% blocktrans %}</code>标签可以标记包含常量和占位符的内容用于翻译，下边的例子展示了使用一个<code>name</code>变量的翻译：</p>
<pre>{% blocktrans %}Hello {{ name }}!{% endblocktrans %}</pre>
<p>可以使用with，将具体的表达式设置为变量的值，此时在<code>blocktrans</code>块内部不能够再继续访问表达式和对象的属性，下面是一个使用了<code>capfirst</code>装饰器的例子：</p>
<pre>
{% blocktrans with name=user.name|capfirst %}
    Hello {{ name }}!
{% endblocktrans %}
</pre>
<p class="hint">如果翻译内容中包含变量，使用<code>{% blocktrans %}</code>代替<code>{% trans %}</code>。</p>

<h4 id="c9-2-4-3"><span class="title">2.4.3</span><b>翻译商店模板</b></h4>
<p>编辑<code>shop</code>应用的<code>base.html</code>，在其顶部加入<code>i18n</code>标签，然后标注如下要翻译的部分：</p>
<pre>
<b>{% load i18n %}</b>
{% load static %}
&lt;!DOCTYPE html>
&lt;html>
&lt;head>
    &lt;meta charset="utf-8"/>
    &lt;title>{% block title %}<b>{% trans "My shop" %}</b>{% endblock %}&lt;/title>
    &lt;link href="{% static "css/base2.css" %}" rel="stylesheet">
&lt;/head>
&lt;body>
&lt;div id="header">
    &lt;a href="/" class="logo"><b>{% trans "My shop" %}</b>&lt;/a>
&lt;/div>
&lt;div id="subheader">
    &lt;div class="cart">
        {% with total_items=mycart|length %}
            {% if mycart|length > 0 %}
                <b>{% trans "Your cart" %}</b>:
                &lt;a href="{% url 'cart:cart_detail' %}">
                    <b>{% blocktrans with total_items_plural=total_items|pluralize total_price=cart.get_total_price %}</b>
                    <b>{{ total_items }} items{{ total_items_plural }}, ${{ total_price }}</b>
                    <b>{% endblocktrans %}</b>
                &lt;/a>
            {% else %}
                <b>{% trans "Your cart is empty." %}</b>
            {% endif %}
        {% endwith %}
    &lt;/div>
&lt;/div>
&lt;div id="content">
    {% block content %}
    {% endblock %}
&lt;/div>
&lt;/body>
&lt;/html>
</pre>
<p>注意<code>{% blocktrans %}</code>展示购物车总价部分的方法，在原来的模板中，我们使用了：</p>
<pre>
{{ total_items }} item{{ total_items|pluralize }},
${{ cart.get_total_price }}
</pre>
<p>现在改用<code>{% blocktrans with ... %}</code>来为<code>total_items|pluralize</code>（使用了过滤器）和<code>cart.get_total_price</code>（访问对象的方法）创建占位符：</p>
<p>编辑<code>shop</code>应用的<code>shop/product/detail.html</code>，紧接着<code>{% extends %}</code>标签导入<code>i18n</code>标签：</p>
<pre>{% load i18n %}</pre>
<p>之后找到下边这一行：</p>
<pre>&lt;input type="submit" value="Add to cart"></pre>
<p>将其替换成：</p>
<pre>&lt;input type="submit" value="{% trans "Add to cart" %}"></pre>
<p>现在来翻译<code>orders</code>应用，编辑<code>orders/order/create.html</code>，标记如下翻译内容：</p>
<pre>
{% extends 'shop/base.html' %}
<b>{% load i18n %}</b>
{% block title %}
    <b>{% trans "Checkout" %}</b>
{% endblock %}

{% block content %}
    &lt;h1><b>{% trans "Checkout" %}</b>&lt;/h1>

    &lt;div class="order-info">
        &lt;h3><b>{% trans "Your order" %}</b>&lt;/h3>
        &lt;ul>
            {% for item in cart %}
                &lt;li>
                    {{ item.quantity }}x {{ item.product.name }}
                    &lt;span>${{ item.total_price|floatformat:"2" }}&lt;/span>
                &lt;/li>
            {% endfor %}
            {% if cart.coupon %}
                &lt;li>
                    <b>{% blocktrans with code=cart.coupon.code discount=cart.coupon.discount %}</b>
                        <b>"{{ code }}" ({{ discount }}% off)</b>
                    <b>{% endblocktrans %}</b>
                    &lt;span>- ${{ cart.get_discount|floatformat:"2" }}&lt;/span>
                &lt;/li>
            {% endif %}
        &lt;/ul>
        &lt;p><b>{% trans "Total" %}</b>: ${{ cart.get_total_price_after_diccount|floatformat:"2" }}&lt;/p>
    &lt;/div>

    &lt;form action="." method="post" class="order-form" novalidate>
        {{ form.as_p }}
        &lt;p>&lt;input type="submit" value="<b>{% trans "Place order" %}</b>">&lt;/p>
        {% csrf_token %}
    &lt;/form>
{% endblock %}
</pre>
<p>到现在我们完成了如下文件的翻译：</p>
<ul>
    <li><code>shop</code>应用的<code>shop/product/list.html</code>模板</li>
    <li><code>orders</code>应用的<code>orders/order/created.html</code>模板</li>
    <li><code>cart</code>应用的<code>cart/detail.html</code>模板</li>
</ul>
<p>之后来更新消息文件，打开命令行窗口执行：</p>
<pre>django-admin makemessages --all</pre>
<p>此时<code>myshop</code>项目下的<code>locale</code>目录内有了对应的<code>.po</code>文件，而<code>orders</code>应用的翻译文件优先存放在应用内部的<code>locale</code>目录中。</p>
<p>编辑所有<code>.po</code>文件，在<code>msgstr</code>属性内添加西班牙语翻译。你也可以直接复制随书代码内对应文件的内容。</p>
<p>执行命令编译消息文件：</p>
<pre>django-admin compilemessages</pre>
<p>可以看到如下输出：</p>
<pre>
processing file django.po in myshop/locale/en/LC_MESSAGES
processing file django.po in myshop/locale/es/LC_MESSAGES
processing file django.po in myshop/orders/locale/en/LC_MESSAGES
processing file django.po in myshop/orders/locale/es/LC_MESSAGES
</pre>
<p>针对每一个<code>.po</code>文件都会生成对应的<code>.mo</code>文件。</p>

<h3 id="c9-2-5"><span class="title">2.5</span>使用Rosetta翻译界面</h3>
<p>Rosetta是一个第三方应用，通过Django管理后台编辑所有翻译内容，让<code>.po</code>文件的管理变得更加方便，先通过<code>pip</code>安装该模块：</p>
<pre>
pip install django-rosetta==0.8.1
</pre>
<p>之后在<code>settings.py</code>中激活该应用：</p>
<pre>
INSTALLED_APPS = [
    # ...
    <b>'rosetta',</b>
]
</pre>
<p>然后需要为Rosetta配置相应的URL，其二级路由已经配置好，修改项目根路由增加一行：</p>
<pre>
urlpatterns = [
    # ...
    <b>path('rosetta/', include('rosetta.urls')),</b>
    path('', include('shop.urls', namespace='shop')),
]
</pre>
<p>这条路径也需要在<code>shop.urls</code>上边。</p>
<p>然后启动站点，使用管理员身份登录<a href="http://127.0.0.1:8000/rosetta/" target="_blank">http://127.0.0.1:8000/rosetta/</a> ，再转到<a
        href="http://127.0.0.1:8000/rosetta/" target="_blank">http://127.0.0.1:8000/rosetta/</a>，点击右上的THIRD PARTY以列出所有的翻译文件，如下图所示：</p>
<p><img src="http://img.conyli.cc/django2/C09-05.jpg" alt=""></p>
<p>点开Spanish下边的Myshop链接，可以看到列出了所有需要翻译的内容：</p>
<p><img src="http://img.conyli.cc/django2/C09-06.jpg" alt=""></p>
<p>可以手工编辑需要翻译的地方，OCCURRENCES(S)栏显示了该翻译所在的文件名和行数，对于那些占位符翻译的内容，显示为这样：</p>
<p><img src="http://img.conyli.cc/django2/C09-07.jpg" alt=""></p>
<p>Rosetta对占位符使用了不同的背景颜色，在手工输入翻译内容的时候注意不要破坏占位符的结构，例如要翻译下边这一行：</p>
<pre>%(total_items)s item%(total_items_plural)s, $%(total_price)s</pre>
<p>应该输入：</p>
<pre>%(total_items)s producto%(total_items_plural)s, $%(total_price)s</pre>
<p>可以参考本章随书代码中的西班牙语翻译来录入翻译内容。</p>
<p>结束输入的时候，点击一下Save即可将当前翻译的内容保存到<code>.po</code>文件中，当保存之后，Rosetta会自动进行编译，所以无需执行<code>compilemessages</code>命令。然而要注意Rosetta会直接读写<code>locale</code>目录，注意要给予其相应的权限。</p>
<p>如果需要其他用户来编辑翻译内容，可以到<a
        href="http://127.0.0.1:8000/admin/auth/group/add/" target="_blank">http://127.0.0.1:8000/admin/auth/group/add/</a>新增一个用户组叫<code>translators</code>，然后到<a
        href="http://127.0.0.1:8000/admin/auth/user/" target="_blank">http://127.0.0.1:8000/admin/auth/user/</a>编辑用户的权限以给予其修改翻译的权限，将该用户加入到<code>translators</code>用户组内。仅限超级用户和<code>translators</code>用户组内的用户才能使用Rosetta。</p>
<p>Rosetta的官方文档在<a href="https://django-rosetta.readthedocs.io/en/latest/" target="_blank">https://django-rosetta.readthedocs.io/en/latest/</a>。</p>
<p class="hint">特别注意的是，当Django已经在生产环境运行时，如果修改和新增了翻译，在运行了<code>compilemessages</code>命令之后，只有重新启动Django才会让新的翻译生效。</p>

<h3 id="c9-2-6"><span class="title">2.6</span>待校对翻译Fuzzy translations</h3>
<p>你可能注意到了，Rosetta页面上有一列叫做Fuzzy。这不是Rosetta的功能，而是<code>gettext</code>提供的功能。如果将fuzzy设置为true，则该条翻译不会包含在编译后的消息文件中。这个字段用来标记需要由用户进行检查的翻译内容。当<code>.po</code>文件更新了新的翻译字符串时，很可能一些翻译被自动标成了fuzzy。这是因为：在<code>gettext</code>发现一些<code>msgid</code>被修改过的时候，<code>gettext</code>会将其与它认为的旧有翻译进行匹配，然后标注上fuzzy。看到fuzzy出现的时候，人工翻译者必须检查该条翻译，然后取消fuzzy，之后再行编译。</p>

<h3 id="c9-2-7"><span class="title">2.7</span>国际化URL</h3>
<p>Django提供两种国际化URL的特性：</p>
<ul>
    <li><b>Language prefix in URL patterns</b> 语言前缀URL模式：在URL的前边加上不同的语言前缀构成不同的基础URL</li>
    <li><b>Translated URL patterns</b> 翻译URL模式：基础URL相同，把基础URL按照不同语言翻译给用户得到不同语言的URL</li>
</ul>
<p>使用翻译URL模式的优点是对搜索引擎友好。如果采用语言前缀URL，则必须要为每一种语言进行索引，使用翻译URL模式，则一条URL就可以匹配全部语言。下边来看一下两种模式的使用：</p>

<h4 id="c9-2-7-1"><span class="title">2.7.1</span><b>语言前缀URL模式</b></h4>

<h3><b></b></h3>
<p>Django可以为不同语言在URL前添加前缀，例如我们的网站，英语版以<code>/en/</code>开头，而西班牙语版以<code>/es/</code>开头。</p>
<p>要使用语言前缀URL模式，需要启用<code>LocaleMiddleware</code>中间件，用于从不同的URL中识别语言，在之前我们已经添加过该中间件。</p>
<p>我们来为URL模式增加前缀，现在需要修改项目的根<code>urls.py</code>文件：</p>
<pre>
from django.conf.urls.i18n import i18n_patterns

urlpatterns = <b>i18n_patterns</b>(
    path('admin/', admin.site.urls),
    path('cart/', include('cart.urls', namespace='cart')),
    path('orders/', include('orders.urls', namespace='orders')),
    path('pyament/', include('payment.urls', namespace='payment')),
    path('coupons/', include('coupons.urls', namespace='coupons')),
    path('rosetta/', include('rosetta.urls')),
    path('', include('shop.urls', namespace='shop')),
)
</pre>
<p>可以混用未经翻译的标准URL与<code>i18n_patterns</code>类型的URL，使部分URL带有语言前缀，部分不带前缀。但最好只使用翻译URL，以避免把翻译过的URL匹配到未经翻译过的URL模式上。</p>
<p>现在启动站点，到<a href="http://127.0.0.1:8000/" target="_blank">http://127.0.0.1:8000/</a> ，Django的语言中间件会按照之前介绍的顺序来确定本地语言，然后重定向到带有语言前缀的URL。现在看一下浏览器的地址栏，应该是<a href="http://127.0.0.1:8000/en/" target="_blank">http://127.0.0.1:8000/en/</a>。当前语言是由请求头<code>Accept-Language</code>所设置，或者就是<code>LANGUAGE_CODE</code>的设置。</p>

<h4 id="c9-2-7-2"><span class="title">2.7.2</span><b>翻译URL模式</b></h4>
<p>Django支持在URL模式中翻译字符串。针对不同的语言，可以翻译出不同的URL。在<code>urls.py</code>中，使用<code>ugettext_lazy()</code>来标注字符串。</p>
<p>编辑<code>myshop</code>应用的根<code>urls.py</code>，为<code>cart</code>，<code>orders</code>，<code>payment</code>和<code>coupons</code>应用配置URL：</p>
<pre>
<b>from django.utils.translation import gettext_lazy as _</b>

urlpatterns = i18n_patterns(
    path(<b>_('admin/')</b>, admin.site.urls),
    path(<b>_('cart/')</b>, include('cart.urls', namespace='cart')),
    path(<b>_('orders/')</b>, include('orders.urls', namespace='orders')),
    path(<b>_('payment/')</b>, include('payment.urls', namespace='payment')),
    path(<b>_('coupons/')</b>, include('coupons.urls', namespace='coupons')),
    path('rosetta/', include('rosetta.urls')),
    path('', include('shop.urls', namespace='shop')),
)
</pre>
<p>编辑<code>orders</code>应用的<code>urls.py</code>文件，修改成如下：</p>
<pre>
<b>from django.utils.translation import gettext_lazy as _</b>

urlpatterns = [
    path(<b>_('create/')</b>, views.order_create, name='order_create'),
    # ...
]
</pre>
<p>修改<code>payment</code>应用的<code>urls.py</code>文件，修改成如下：</p>
<pre>
<b>from django.utils.translation import gettext_lazy as _</b>

urlpatterns = [
    path(<b>_('process/')</b>, views.payment_process, name='process'),
    path(<b>_('done/')</b>, views.payment_done, name='done'),
    path(<b>_('canceled/')</b>, views.payment_canceled, name='canceled'),
]
</pre>
<p>对于<code>shop</code>应用的URL不需要修改，因为其URL是动态建立的。</p>
<p>执行命令进行编译，更新消息文件：</p>
<pre>django-admin makemessages --all</pre>
<p>启动站点，访问<a href="http://127.0.0.1:8000/en/rosetta/" target="_blank">http://127.0.0.1:8000/en/rosetta/</a>，点击Spanish下的Myshop，可以看到出现了URL对应的翻译。可以点击Untranslated查看所有尚未翻译的字符串，然后输入翻译内容。</p>

<h3 id="c9-2-8"><span class="title">2.8</span>允许用户切换语言</h3>
<p>在之前的工作中，我们配置好了英语和西班牙语的翻译，应该给用户提供切换语言的选项，为此准备给网站增加一个语言选择器，列出所有支持的语言，显示为一系列链接。</p>
<p>编辑<code>shop</code>应用下的<code>base.html</code>，找到下边这三行：</p>
<pre>
&lt;div id="header">
    &lt;a href="/" class="logo">{% trans "My shop" %}&lt;/a>
&lt;/div>
</pre>
<p>将其替换成：</p>
<pre>
&lt;div id="header">
    &lt;a href="/" class="logo">{% trans "My shop" %}&lt;/a>
    <b>{% get_current_language as LANGUAGE_CODE %}</b>
    <b>{% get_available_languages as LANGUAGES %}</b>
    <b>{% get_language_info_list for LANGUAGES as languages %}</b>
    <b>&lt;div class="languages"></b>
        <b>&lt;p>{% trans "Language" %}:&lt;/p></b>
        <b>&lt;ul class="languages"></b>
            <b>{% for language in languages %}</b>
                <b>&lt;li></b>
                    <b>&lt;a href="/{{ language.code }}/"</b>
                       <b>{% if language.code == LANGUAGE_CODE %} class="selected"{% endif %}></b>
                        <b>{{ language.name_local }}</b>
                    <b>&lt;/a></b>
                <b>&lt;/li></b>
            <b>{% endfor %}</b>
        <b>&lt;/ul></b>
    <b>&lt;/div></b>
&lt;/div>
</pre>
<p>这个就是我们的语言选择器，逻辑如下：</p>
<ol>
    <li>页面的最上方加载<code>{% load i18n %}</code></li>
    <li>使用<code>{% get_current_language %}</code>标签用于获取当前语言</li>
    <li>使用<code>{% get_available_languages %}</code>标签用于从<code>LANGUAGES</code>里获取所有可用的支持语言</li>
    <li>使用<code>{% get_language_info_list %}</code>是为了快速获取语言的属性而设置的变量</li>
    <li>用循环列出了所有可支持的语言，对于当前语言设置CSS类为<code>select</code></li>
</ol>
<p>启动站点到<a href="http://127.0.0.1:8000/">http://127.0.0.1:8000/</a> ，可以看到页面右上方出现了语言选择器，如下图：</p>
<p><img src="http://img.conyli.cc/django2/C09-08.jpg" alt=""></p>

<h3 id="c9-2-9"><span class="title">2.9</span>使用django-parler翻译模型</h3>
<p>Django没有提供直接可用的模型翻译功能，必须采用自己的方式实现模型翻译。有一些第三方工具可以翻译模型字段，每个工具存储翻译的方式都不相同。其中一个工具叫做<code>django-parler</code>，提供了高效的翻译管理，还能够与管理后台进行集成。</p>
<p><code>django-parler</code>的工作原理是为每个模型建立一个对应的翻译数据表，表内每条翻译记录通过外键连到翻译文字所在的模型，表内还有一个<code>language</code>字段，用于标记是何种语言。</p>

<h4 id="c9-2-9-1"><span class="title">2.9.1</span><b>安装django-parler</b></h4>
<p>使用<code>pip</code>安装<code>django-parler</code>：</p>
<pre>
pip install django-parler==1.9.2
</pre>
<p>在<code>settings.py</code>内激活该应用：</p>
<pre>
INSTALLED_APPS = [
    # ...
    <b>'parler',</b>
]
</pre>
<p>继续添加下列设置：</p>
<pre>
PARLER_LANGUAGES = {
    None: (
        {'code': 'en'},
        {'code': 'es'},
    ),
    'default': {
        'fallback': 'en',
        'hide_untranslated': False,
    }
}
</pre>
<p>该配置的含义是指定了<code>django-parler</code>的可用语言为<code>en</code>和<code>es</code>，然后指定了默认语言为<code>en</code>，然后指定<code>django-parler</code>不要隐藏未翻译的内容。</p>

<h4 id="c9-2-9-2"><span class="title">2.9.2</span><b>翻译模型字段</b></h4>
<p>我们为商品品类添加翻译。<code>django-parler</code>提供一个<code>TranslatableModel</code>类<span style="color: red;">（此处作者原文有误，写成了<code>TranslatedModel</code>）</span>和<code>TranslatedFields</code>方法来翻译模型的字段。编辑<code>shop</code>应用的<code>models.py</code>文件，添加导入语句：</p>
<pre>from parler.models import TranslatableModel, TranslatedFields
</pre>
<p>然后修改<code>Category</code>模型的<code>name</code>和<code>slug</code>字段：</p>
<pre>
class Category(<b>TranslatableModel</b>):
    <b>translations = TranslatedFields(</b>
        <b>name=models.CharField(max_length=200, db_index=True),</b>
        <b>slug=models.SlugField(max_length=200, db_index=True, unique=True)</b>
    <b>)</b>
</pre>
<p><code>Category</code>类现在继承了<code>TranslatableModel</code>类，而不是原来的<code>models.Model</code>，<code>name</code>和<code>slug</code>字段被包含在了<code>TranslatedFields</code>包装器里。</p>
<p>编辑<code>Product</code>，<code>name</code>，<code>slug</code>，<code>description</code>，和上边一样的方式：</p>
<pre>
class Product(<b>TranslatableModel</b>):
    <b>translations = TranslatedFields(</b>
        <b>name=models.CharField(max_length=200, db_index=True),</b>
        <b>slug=models.SlugField(max_length=200, db_index=True),</b>
        <b>description=models.TextField(blank=True)</b>
    <b>)</b>
    category = models.ForeignKey(Category, related_name='products')
    image = models.ImageField(upload_to='products/%Y/%m/%d', blank=True)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    available = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
</pre>
<p><code>django-parler</code>通过新创建模型为其他模型提供翻译，在下图可以看到<code>Product</code>与其对应的翻译模型<code>ProductTranslation</code>之间的关系：</p>
<p><img src="http://img.conyli.cc/django2/C09-10.jpg" alt=""></p>
<p class="emp">译者注：此时如果运行站点，一些IDE会提示模型的字段找不到，这个对于实际运行程序没有影响，该字段依然可用。</p>
<p><code>django-parler</code>生成的<code>ProductTranslation</code>类包含<code>name</code>，<code>slug</code>，<code>description</code>，和一个<code>language_code</code>字段，还有一个外键连接到<code>Product</code>类，针对一个<code>Product</code>模型，会按照每种语言生成一个对应的<code>ProductTranslation</code>对象。</p>
<p>由于翻译的部分和原始的类是独立的两个模型，因此一些ORM的功能无法使用，比如不能在<code>Product</code>类中根据一个翻译后的字段进行排序，也不能在<code>Meta</code>类的<code>ordering</code>属性中使用翻译的字段。</p>
<p>所以编辑<code>shop</code>应用的<code>models.py</code>文件，注释掉<code>ordering</code>设置：</p>
<pre>
class Category(TranslatableModel):
    # ...
    class Meta:
        <b># ordering = ('name',)</b>
        verbose_name = 'category'
        verbose_name_plural = 'categories'
</pre>
<p>对于<code>Product</code>类，也要注释掉<code>ordering</code>，还需要注释掉<code>index_together</code>，这是因为目前的<code>django-parler</code>不支持联合索引的验证关系。如下图：</p>
<pre>
class Product(TranslatableModel):
    # ...
    class Meta:
        <b>pass</b>
        <b># ordering = ('name',)</b>
        <b># index_together = (('id', 'slug'),)</b>
</pre>
<p class="emp">译者注：原书在这里遗漏了pass，不要忘记加上。</p>
<p>关于<code>django-parler</code>的兼容性，可以在<a href="https://django-parler.readthedocs.io/en/latest/compatibility.html" target="_blank">https://django-parler.readthedocs.io/en/latest/compatibility.html</a>查看。</p>

<h4 id="c9-2-9-3"><span class="title">2.9.3</span><b>将<code>django-parler</code>集成到管理后台</b></h4>
<p><code>django-parler</code>易于集成到django管理后台中，包含一个<code>TranslatableAdmin</code>类代替了原来的<code>ModelAdmin</code>类。</p>
<p>编辑<code>shop</code>应用的<code>admin.py</code>文件，导入该类：</p>
<pre>
from parler.admin import TranslatableAdmin
</pre>
<p>修改<code>CategoryAdmin</code>和<code>ProductAdmin</code>类，使其继承<code>TranslatableAdmin</code>而不是<code>ModelAdmin</code>类，<code>django-parler</code>不支持<code>prepopulated_fields</code>属性，但支持相同功能的<a
        href="https://docs.djangoproject.com/en/2.1/_modules/django/contrib/admin/options/" target="_blank">get_prepopulated_fields()方法</a>，因此将两个类修改如下：</p>
<pre>
from django.contrib import admin
from .models import Category, Product
<b>from parler.admin import TranslatableAdmin</b>

@admin.register(Category)
class CategoryAdmin(<b>TranslatableAdmin</b>):
    list_display = ['name', 'slug']

    <b>def get_prepopulated_fields(self, request, obj=None):</b>
        <b>return {'slug': ('name',)}</b>


@admin.register(Product)
class ProductAdmin(<b>TranslatableAdmin</b>):
    list_display = ['name', 'slug', 'price', 'available', 'created', 'updated']
    list_filter = ['available', 'created', 'updated']
    list_editable = ['price', 'available']

    <b>def get_prepopulated_fields(self, request, obj=None):</b>
        <b>return {'slug': ('name',)}</b>
</pre>
<p>现在在管理后台内也能进行对翻译模型的管理了。现在可以执行数据迁移程序。</p>

<h4 id="c9-2-9-4"><span class="title">2.9.4</span>迁移翻译模型数据</h4>
<p>打开shell执行下列命令：</p>
<pre>
python manage.py makemigrations shop --name "translations"
</pre>
<p>会看到如下输出：</p>
<pre>
Migrations for 'shop':
  shop\migrations\0002_translations.py
    - Create model CategoryTranslation
    - Create model ProductTranslation
    - Change Meta options on category
    - Change Meta options on product
    - Remove field name from category
    - Remove field slug from category
    - Alter index_together for product (0 constraint(s))
    - Add field master to producttranslation
    - Add field master to categorytranslation
    - Remove field description from product
    - Remove field name from product
    - Remove field slug from product
    - Alter unique_together for producttranslation (1 constraint(s))
    - Alter unique_together for categorytranslation (1 constraint(s))
</pre>
<p><code>django-parler</code>动态地创建了<code>CategoryTranslation</code>和<code>ProductTranslation</code>。注意，原模型中需要翻译的字段从原模型中删除了，这意味着这几个字段的数据全都丢失了，必须启动站点后重新录入。</p>
<p>之后运行数据迁移：</p>
<pre>
python manage.py migrate shop
</pre>
<p>可以看到下列输出：</p>
<pre>Applying shop.0002_translations... OK</pre>
<p>现在数据已经和数据库同步好了。</p>
<p>启动站点，访问<a href="http://127.0.0.1:8000/en/admin/shop/category/" target="_blank">http://127.0.0.1:8000/en/admin/shop/category/</a>，可以看到已经存在的模型失去了那些需要翻译的字段。点击一个<code>category</code>对象进行修改，可以看到包含了两个不同的表格，一个对应英语，一个对应西班牙语，如下图所示：</p>
<p><img src="http://img.conyli.cc/django2/C09-11.jpg" alt=""></p>
<p>为所有已存在category记录都添加名称和简称，再为其添加西班牙语的名称和简称，然后点击SAVE按钮，确保在切换标签之前点击了SAVE按钮，否则数据不会被保存。</p>
<p>之后到<a href="http://127.0.0.1:8000/en/admin/shop/product/" target="_blank">http://127.0.0.1:8000/en/admin/shop/product/</a>进行同样的工作：补充每个商品的名称、简称、描述以及对应的西班牙语翻译。</p>

<h4 id="c9-2-9-5"><span class="title">2.9.5</span>视图中加入翻译功能</h4>
<p>为了正常使用翻译后的模型，必须让<code>shop</code>应用的视图对翻译后的字段也能够获取QuerySet，终端内输入<code>python manage.py shell</code>进入带Django环境的命令行模式来试验一下经过翻译后的查询操作：</p>
<p>看一下如何查询翻译后的字段。为了获取某种语言的查询结果集，需要使用Django的<code>activate()</code>函数：</p>
<pre>
>>> from shop.models import Product
>>> from django.utils.translation import activate
>>> activate('es')
>>> product=Product.objects.first()
>>> product.name
'Té verde'
</pre>
<p>另外一种根据不同语言查询的方式是使用<code>django-parler</code>提供的<code>language()</code>模型管理器：</p>
<pre>
>>> product=Product.objects.language('en').first()
>>> product.name
'Green tea'
</pre>
<p>当查询翻译字段时，会根据所指定的语言返回结果。可以通过设置管理器的属性得到不同语言的结果，类似这样：</p>
<pre>
>>> product.set_current_language('es')
>>> product.name
'Té verde'
>>> product.get_current_language()
'es'
</pre>
<p>如果需要使用<code>filter</code>功能，需要使用<code>tranlations__</code>语法，例子如下：</p>
<pre>
>>> Product.objects.filter(translations__name='Green tea')
&lt;TranslatableQuerySet [&lt;Product: Té verde>]>
</pre>
<p>了解了基础操作，可以来修改我们自己的视图中的查询方法了，修改<code>shop</code>应用中的<code>views.py</code>，找到<code>product_list</code>视图中如下这行：</p>
<pre>category = get_object_or_404(Category, slug=category_slug)</pre>
<p>替换成如下内容：</p>
<pre>
<b>language = request.LANGUAGE_CODE</b>
category = get_object_or_404(Category, <b>translations__language_code=language, translations__slug=category_slug</b>)
</pre>
<p>然后编辑<code>product_detail</code>视图，找到下边这行：</p>
<pre>product = get_object_or_404(Product, id=id, slug=slug, available=True)</pre>
<p>替换成如下内容：</p>
<pre>
<b>language = request.LANGUAGE_CODE</b>
product = get_object_or_404(Product, id=id, <b>translations__language_code=language, translations__slug=slug,</b>
                                <b>available=True</b>)
</pre>
<p><code>product_list</code>和<code>product_detail</code>现在都具备了根据翻译字段查询数据库的功能。启动站点，到<a href="http://127.0.0.1:8000/es/" target="_blank">http://127.0.0.1:8000/es/</a>，应该可以看到商品名称全部都变成了西班牙语，如下图：</p>
<p><img src="http://img.conyli.cc/django2/C09-12.jpg" alt=""></p>
<p>可以看到通过每个商品的<code>slug</code>字段生成的URL也变成了西班牙语。比如一个商品的URL在西班牙语下是<code>http://127.0.0.1:8000/es/2/te-rojo/</code>，在英语里则是<code>http://127.0.0.1:8000/en/2/red-tea/</code>。如果到一个商品详情页，能够看到翻译后的URL和内容如下：</p>
<p><img src="http://img.conyli.cc/django2/C09-13.jpg" alt=""></p>
<p>在<a href="https://django-parler.readthedocs.io/en/latest/" target="_blank">https://django-parler.readthedocs.io/en/latest/</a>可以查看<code>django-parler</code>的文档。</p>
<p>现在已经知道了如何翻译Python代码，模板，URL和模型的字段，站点已经可以提供不同语言的服务了。为了完成国际化和本地化的过程，还需要对本地的日期，时间，数字格式进行设置。</p>

<h3 id="c9-2-10"><span class="title">2.10</span>本地格式化</h3>
<p>根据用户的国家和地区，需要以不同的格式显示日期，时间和数字。本地化格式可以通过<code>settings.py</code>里的<code>USE_L10N</code>设置为<code>True</code>来开启。</p>
<p>当<code>USE_L10N</code>设置为开启的时候，Django在渲染模板的时候，会尽可能的尝试使用当前本地化的方式进行输出。可以看到我们的站点的小数点是一个圆点显示的，切换到西班牙语的时候，小数点显示为一个逗号。这是通过对每种语言进行不同的格式设置实现的，对于支持的每种语言的格式，Django都有对应的配置文件，例如针对西班牙语的配置文件可以查看<a
        href="https://github.com/django/django/blob/stable/2.0.x/django/conf/locale/es/formats.py" target="_blank">https://github.com/django/django/blob/stable/2.0.x/django/conf/locale/es/formats.py</a>。</p>
<p>通常情况下，只要设置<code>USE_L10N</code>为<code>True</code>，Django就会自动应用本地化格式。然而，站点内可能有些内容并不想使用本地化格式，尤其那些标准数据例如代码或者是JSON字符串的内容。</p>
<p>Django提供了一个<code>{% locailze %}</code>模板标签，用于控制模板或者模板片段开启或关闭本地化输出。为了使用这个标签，必须在模板开头使用<code>{% load l10n %}</code>标签。下边是一个如何在模板中控制开启/关闭本地化输出的例子：</p>
<pre>
{% load l10n %}

{% localize on %}
    {{ value }}
{% endlocalize %}

{% localize off %}
    {{ value }}
{% endlocalize %}
</pre>
<p>Django还提供了两个模板过滤器用于控制本地化，分别是<code>localize</code>和<code>unlocailze</code>，用来强制让一个值开启/关闭本地化显示。用法如下：</p>
<pre>
{{ value|localize }}
{{ value|unlocalize }}
</pre>
<p>除了这两个方法之外，还可以采取自定义格式文件方式，具体看<a href="https://docs.djangoproject.com/en/2.0/topics/i18n/formatting/#creating-custom-format-files" target="_blank">https://docs.djangoproject.com/en/2.0/topics/i18n/formatting/#creating-custom-format-files</a>。</p>

<h3 id="c9-2-11"><span class="title">2.11</span>用django-localflavor验证表单字段</h3>

<h3><b></b></h3>
<p><code>django-localflavor</code>是一个第三方模块，包含一系列特别针对本地化验证的工具，比如为每个国家单独设计的表单和模型字段，对于验证某些国家的地区，电话号码，身份证，社会保险号码等非常方便。这个模块是按照ISO 3166国家代码标准编写的。</p>
<p>安装<code>django-localflavor</code>：</p>
<pre>
pip install django-localflavor==2.0
</pre>
<p>在<code>settings.py</code>中激活该应用：</p>
<pre>
INSTALLED_APPS = [
    # ...
    <b>'localflavor',</b>
]
</pre>
<p>为了使用该模块，我们给订单增加一个美国邮编字段和对应验证，必须是一个有效的美国邮编才能建立订单。</p>
<p>编辑<code>orders</code>应用的<code>forms.py</code>文件，修改成如下：</p>
<pre>
<b>from localflavor.us.forms import USZipCodeField</b>

class OrderCreateForm(forms.ModelForm):
    <b>postal_code = USZipCodeField()</b>
    class Meta:
        model = Order
        fields = ['first_name', 'last_name', 'email', 'address', 'postal_code', 'city']
</pre>
<p>从<code>localflaver</code>的<code>us</code>模块中导入<code>USZipCodeField</code>字段类型，将<code>OrderCreateForm</code>类的<code>postal_code</code>字段设置为该类型。</p>
<p>运行站点，到<a href="http://127.0.0.1:8000/en/orders/create/" target="_blank">http://127.0.0.1:8000/en/orders/create/</a>，输入一些不符合美国邮编的邮政编码，可以看到表单的错误提示：</p>
<pre>
Enter a zip code in the format XXXXX or XXXXX-XXXX.
</pre>
<p>这只是一个针对给字段附加本地化验证的一个简单例子。<code>localflavor</code>提供的组件对于将站点快速适配到某些国家非常有用。可以在<a href="https://django-localflavor.readthedocs.io/en/latest/" target="_blank">https://django-localflavor.readthedocs.io/en/latest/</a>阅读<code>django-flavor</code>的官方文档。</p>
<p>现在就结束了所有国际化和本地化配置的工作，下一步是建立一个商品推荐系统。</p>

<h2 id="c9-3"><span class="title">3</span>创建商品推荐系统</h2>
<p>商品推荐系统可以预测用户对一个商品的喜好程度或者评价高低，根据用户的行为和收集到的用户数据，选择可能和用户相关的产品推荐给用户。在电商行业，推荐系统使用的非常广泛。推荐系统可以帮助用户从浩如烟海的商品中选出自己感兴趣的商品。好的推荐系统可以增加用户粘性，对电商平台则意味着销售额的提高。</p>
<p>我们准备建立一个简单但是强大的商品推荐系统，用于推荐经常被一起购买的商品，这些商品基于用户过去的购买数据来给用户进行推荐。我们打算在两个页面向用户推荐商品：</p>
<ul>
    <li>首先是商品详情页。我们会在此展示一些与当前商品一起购买的商品。展示的文字类似：Users who bought this also bought X, Y, Z. 所以我们需要一个数据结构来存放所有与该商品一同购买的次数。</li>
    <li>其次是购物车详情页。这时将不同商品与购物车中所有商品的关联购买次数进行求和再进行排名。</li>
</ul>
<p>我们将使用Redis数据库记录一起购买的商品。我们在第六章已经使用过Redis，如果还没有安装Redis，可以参考该章节的内容。</p>


<h3 id="c9-3-1"><span class="title">3.1</span>根据之前的购买记录推荐商品</h3>
<p>现在，需要根据用户加入到购物车内的商品计算排名。对于我们网站每一个被售出的商品，在Redis中存一个键。这个商品键对应的值是一个有序集合，就为同订单的其他商品在当前商品键对应的有序集合中的分数加1。</p>
<p>当一个订单成功支付时，我们为订单每个购买的商品存储一个有序集合，这个有序集合将记录一起购买的商品分数。</p>
<p>安装<code>redis-p</code>模块：</p>
<pre>
pip install redis==2.10.6
</pre>
<p>之后在<code>settings.py</code>里配置Redis：</p>
<pre>
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1
</pre>
<p>这是用于建立和Redis服务通信的设置。在<code>shop</code>应用目录下新建<code>recommender.py</code>文件，添加下列代码：</p>
<pre>
import redis
from django.conf import settings
from .models import Product

# 连接到Redis
r = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB)

class Recommender:

    def get_product_key(self, product_id):
        return 'product:{}:purchased_with'.format(product_id)

    def products_bought(self, products):
        product_ids = [p.id for p in products]
        # 针对订单里的每一个商品，将其他商品在当前商品的有序集合中增加1
        for product_id in product_ids:
            for with_id in product_ids:
                if product_id != with_id:
                    r.zincrby(self.get_product_key(product_id), with_id, amount=1)
</pre>
<p>这个<code>Recommender</code>类用来存储订单购买时的相关信息和根据一个指定的对象获取相关的推荐。<code>get_product_key()</code>方法获取一个<code>Product</code>对象的id，然后创建对应的有序集合，其中的键看起来像这样：<code>product:[id]:purchased_with</code>。</p>
<p><code>product_bought()</code>方法接受属于同一个订单的<code>Product</code>对象的列表，然后做如下操作：</p>
<ol>
    <li>获取所有<code>Product</code>对象的ID</li>
    <li>针对每一个ID遍历一次全部的ID，跳过内外循环ID相同的部分，这样就针对其中每个商品都遍历了与其一同购买的商品</li>
    <li>使用<code>get_product_id()</code>方法得到每个商品的Redis键名。例如针对ID为33的商品，返回的键名是<code>product:33:purchased_with</code>，这个键将用于操作有序集合</li>
    <li>在该商品对应的有序序列将同一订单内的其他商品的分数增加1</li>
</ol>
<p>我们现在有了一个保存商品相关信息的方法。还需要一个方法来从Redis中获得推荐的商品，继续编写<code>Recommender</code>类，增加<code>suggest_products_for()</code>方法：</p>
<pre>
class Recommender:
    # ......
    def suggest_products_for(self, products, max_results=6):
        product_ids = [p.id for p in products]
        # 如果当前列表只有一个商品：
        if len(product_ids) == 1:
            suggestions = r.zrange(self.get_product_key(product_ids[0]), 0, -1, desc=True)[:max_results]
        else:
            # 生成一个临时的key，用于存储临时的有序集合
            flat_ids = ''.join([str(id) for id in product_ids])
            tmp_key = 'tmp_{}'.format(flat_ids)
            # 对于多个商品，取所有商品的键名构成keys列表
            keys = [self.get_product_key(id) for id in product_ids]
            # 合并有序集合到临时键
            r.zunionstore(tmp_key, keys)
            # 删除与当前列表内商品相同的键。
            r.zrem(tmp_key, *product_ids)
            # 获得排名结果
            suggestions = r.zrange(tmp_key, 0, -1, desc=True)[:max_results]
            # 删除临时键
            r.delete(tmp_key)
        # 获取关联商品并通过相关性排序
        suggested_products_ids = [int(id) for id in suggestions]
        suggested_products = list(Product.objects.filter(id__in=suggested_products_ids))
        suggested_products.sort(key=lambda x: suggested_products_ids.index(x.id))
        return suggested_products
</pre>
<p><code>suggest_products_for()</code>方法接受两个参数：</p>
<ul>
    <li><code>products</code>：表示为哪些商品进行推荐，可以包含一个或多个商品</li>
    <li><code>max_results</code>：整数值，表示最大推荐几个商品</li>
</ul>
<p>在这个方法里我们做了如下的事情：</p>
<ol>
    <li>获取所有<code>Product</code>对象的ID</li>
    <li>如果仅有一个商品，直接查询这个id对应的有序集合，按降序返回结果。为了实现查询，使用了Redis的<code>ZRANGE</code>命令。我们使用<code>max_results</code>属性指定返回的最大数量。</li>
    <li>如果商品数量多于1个，通过ID创建一个临时键名。</li>
    <li>通过Redis的<code>ZUNIONSTORE</code>命令合并所有商品的有序集合。<code>ZUNIONSTORE</code>合并所有的有序集合中相同键的分数，然后将新生成的有序集合存入临时键。关于该命令可以参考<a
            href="https://redis.io/commands/ZUNIONSTORE" target="_blank">https://redis.io/commands/ZUNIONSTORE</a>。</li>
    <li>由于已经在当前购物车内的商品无需被推荐，因此使用<code>ZREM</code>命令从临时键的有序集合中删除与当前订单内商品id相同的键。</li>
    <li>从临时键中获取商品ID，使用<code>ZRANGE</code>命令按照分数排序，通过<code>max_results</code>控制返回数量，之后删除临时键。</li>
    <li>根据ID获取<code>Product</code>对象，然后按照与取出的ID相同的顺序进行排列。</li>
</ol>
<p>为了更加实用，再给<code>Recommender</code>类添加一个清除推荐商品的方法：</p>
<pre>
class Recommender:
    # ......
    def clear_purchases(self):
        for id in Product.objects.values_list('id', flat=True):
            r.delete(self.get_product_key(id))
</pre>
<p>我们来测试一下推荐引擎是否正常工作。确保<code>Product</code>数据表中有一些商品信息，然后先启动Redis：</p>
<pre>
src/redis-server
</pre>
<p>通过<code>python manage.py shell</code>进入带有Django项目环境的shell中：</p>
<pre>
from shop.models import Product
black_tea = Product.objects.get(translations__name='Black tea')
red_tea = Product.objects.get(translations__name='Red tea')
green_tea = Product.objects.get(translations__name='Green tea')
tea_powder = Product.objects.get(translations__name='Tea powder')
</pre>
<p>之后增加一些测试购买数据：</p>
<pre>
from shop.recommender import Recommender
r = Recommender()
r.products_bought([black_tea, red_tea])
r.products_bought([black_tea, green_tea])
r.products_bought([red_tea, black_tea, tea_powder])
r.products_bought([green_tea, tea_powder])
r.products_bought([black_tea, tea_powder])
r.products_bought([red_tea, green_tea])
</pre>
<p>进行完上述操作后，我们实际为四个商品保存的有序集合是：</p>
<pre>
black_tea: red_tea (2), tea_powder (2), green_tea (1)
red_tea: black_tea (2), tea_powder (1), green_tea (1)
green_tea: black_tea (1), tea_powder (1), red_tea(1)
tea_powder: black_tea (2), red_tea (1), green_tea (1)
</pre>
<p>下边测试一下通过翻译字段获取推荐商品信息：</p>
<pre>
>>> from django.utils.translation import activate
>>> activate('en')
>>> r.suggest_products_for([black_tea])
[&lt;Product: Tea powder>, &lt;Product: Red tea>, &lt;Product: Green tea>]
>>> r.suggest_products_for([red_tea])
[&lt;Product: Black tea>, &lt;Product: Tea powder>, &lt;Product: Green tea>]
>>> r.suggest_products_for([green_tea])
[&lt;Product: Black tea>, &lt;Product: Tea powder>, &lt;Product: Red tea>]
>>> r.suggest_products_for([tea_powder])
[&lt;Product: Black tea>, &lt;Product: Red tea>, &lt;Product: Green tea>]
</pre>
<p>如果看到商品是按照它们的分数进行降序排列的，就说明引擎工作正常了。再测试一下多个商品的推荐：</p>
<pre>
>>> r.suggest_products_for([black_tea, red_tea])
[&lt;Product: Tea powder>, &lt;Product: Green tea>]
>>> r.suggest_products_for([green_tea, red_tea])
[&lt;Product: Black tea>, &lt;Product: Tea powder>]
>>> r.suggest_products_for([tea_powder, black_tea])
[&lt;Product: Red tea>, &lt;Product: Green tea>]
</pre>
<p>可以实际计算一下是否符合合并有序集合后的结果，例如针对第一条程序，<code>tea_powder</code>的分数是2+1，<code>green_tea</code>的分数是1+1等</p>
<p>测试之后说明我们的推荐算法正常工作，下一步就是将该功能集成到站点中，在商品详情页和购物车清单页进行展示。先修改<code>shop</code>应用的<code>views.py</code>文件中的<code>product_detail</code>视图：</p>
<pre>
<b>from .recommender import Recommender</b>

def product_detail(request, id, slug):
    language = request.LANGUAGE_CODE
    product = get_object_or_404(Product, id=id, translations__language_code=language, translations__slug=slug,
                                available=True)

    cart_product_form = CartAddProductForm()

    <b>r = Recommender()</b>
    <b>recommended_products = r.suggest_products_for([product], 4)</b>

    return render(request, 'shop/product/detail.html', {'product': product, 'cart_product_form': cart_product_form,
                                                        <b>'recommended_products': recommended_products</b>})
</pre>
<p>编辑<code>shop/product/detail.html</code>模板，增加下列代码到<code>{{ product.description|linebreaks }}</code>之后：</p>
<pre>
{% if recommended_products %}
    &lt;div class="recommendations">
        &lt;h3>{% trans "People who bought this also bought" %}&lt;/h3>
        {% for p in recommended_products %}
            &lt;div class="item">
                &lt;a href="{{ p.get_absolute_url }}">
                    &lt;img src="{% if p.image %}{{ p.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
                &lt;/a>
                &lt;p>&lt;a href="{{ p.get_absolute_url }}">{{ p.name }}&lt;/a>&lt;/p>
            &lt;/div>
        {% endfor %}
    &lt;/div>
{% endif %}
</pre>
<p>然后运行站点，点击商品进入详情页，可以看到类似下图的商品推荐：</p>
<p><img src="http://img.conyli.cc/django2/C09-14.jpg" alt=""></p>
<p>我们还需要在购物车详情页增加推荐功能，编辑<code>cart</code>应用的<code>views.py</code>文件中的<code>cart_detail</code>视图：</p>
<pre>
<b>from shop.recommender import Recommender</b>

def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(initial={'quantity': item['quantity'], 'update': True})
    coupon_apply_form = CouponApplyForm()

    <b>r = Recommender()</b>
    <b>cart_products = [item['product'] for item in cart]</b>
    <b>recommended_products = r.suggest_products_for(cart_products, max_results=4)</b>

    return render(request, 'cart/detail.html',
                  {'cart': cart, 'coupon_apply_form': coupon_apply_form, <b>'recommended_products': recommended_products</b>})
</pre>
<p>然后修改对应的模板 cart/detail.html，在 <code>&lt;/table&gt;</code> 之后增加下列代码：</p>
<pre>
{% if recommended_products %}
    &lt;div class="recommendations cart">
        &lt;h3>{% trans "People who bought this also bought" %}&lt;/h3>
        {% for p in recommended_products %}
            &lt;div class="item">
                &lt;a href="{{ p.get_absolute_url }}">
                    &lt;img src="{% if p.image %}{{ p.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
                &lt;/a>
                &lt;p>&lt;a href="{{ p.get_absolute_url }}">{{ p.name }}&lt;/a>&lt;/p>
            &lt;/div>
        {% endfor %}
    &lt;/div>
{% endif %}
</pre>
<p class="emp">译者注，由于上述内容使用了{% trans %}模板标签，不要忘记在页面上方加入{% load i18n %}，原书这里没有加，会导致报错。</p>
<p>在浏览器中打开<a href="http://127.0.0.1:8000/en/" target="_blank">http://127.0.0.1:8000/en/</a>。将一些商品加入购物车，然后至<a
        href="http://127.0.0.1:8000/en/cart/" target="_blank">http://127.0.0.1:8000/en/cart/</a>查看购物车详情，可以看到出现了推荐商品：</p>
<p><img src="http://img.conyli.cc/django2/C09-15.jpg" alt=""></p>
<p>现在我们就使用Redis配合Django完成了一个推荐系统。</p>
<p class="emp">译者注，原书其实并没有将功能写完。可以发现，目前的购买数据（调用<code>Recommender</code>类的<code>products_bought()</code>方法）是在我们测试的时候通过命令行添加的，而不是通过网站功能自动添加。按照一开始的分析，应该在付款成功的时候，更新Redis的数据。需要在<code>payment</code>应用的<code>views.py</code>文件中，在<code>payment_process</code>视图中付款响应成功，保存交易id和<code>paid</code>字段之后，发送PDF发票之前，添加如下代码：</p>
<pre>
<b>from shop.recommender import Recommender</b>

def payment_process(request):
    ......
    if request.method == "POST":
    ......
        if result.is_success:
            order.paid = True
            order.braintree_id = result.transaction.id
            order.save()

            <b># 更新Redis中本次购买的商品分数</b>
            <b>r = Recommender()</b>
            <b>order_items = [order_item.product for order_item in order.items.all()]</b>
            <b>r.products_bought(order_items)</b>
</pre>

<h1><b>总结</b></h1>
<p>在这一章，学习了创建优惠码系统和国际化与本地化配置工作。还基于Redis创建了一个商品推荐系统。</p>
<p>在下一章，我们将创建一个新的项目：在线教育平台，里边将使用Django的CBV技术，还会创建一个内容管理系统。</p>
</body>
</html>